<?php

/**
 * MNet Library 
 * Copyright (C) 2006-2008 Catalyst IT Ltd (http://www.catalyst.net.nz)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @package    mnet
 * @subpackage core
 * @author     Catalyst IT Ltd
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL
 * @copyright  (C) 2006-2008 Catalyst IT Ltd http://catalyst.net.nz
 * @copyright  (C) portions from Moodle, (C) Martin Dougiamas http://dougiamas.com
 *
 */

/**
 * This class represents the handler for MNET RPC requests, and dispatches
 * to extensions to handle specific calls
 * 
 * The server will read the required data directly off the POST stream, all
 * you need to do to dispatch a request to the server is call:
 * mnet_server::handle_request();
 * 
 * Once invoked, the server will output directly back to the output buffer,
 * and will not return.
 *
 */
class mnet_server {
    // stores the current remotely connected client
    private static $remote_client;

    // stores the incoming message while it's being processed
    private static $request;

    /**
     * Called statically, this invokes the XMLRPC server and asks it to
     * parse the current request
     * 
     */
    public static function handle_request() {
       $instance = new mnet_server();
       $instance->_handle_request();
    }

    /**
     * This method returns true if the MNet server is currently handling a
     * valid request. Your code may check this before performing an action that
     * should only be dispatched by XML-RPC, as it with return true only iff
     * a valid remote endpoint has called in.
     * 
     * Tip: Use this as an alternative to checking the global self::$remote_client
     * in earlier versions of MNet
     * 
     * @return bool true if we have a valid remote endpoint making a request currently
     */
    public static function is_in_request() {
        return isset(self::$remote_client);
    }
    
    /**
     * Returns the currently connected client
     * 
     * @return mnet_peer the currently connected client, or null if none
     */
    public static function get_current_client() {
        return self::$remote_client;
    }

    /**
     * Returns the raw incoming message, if one exists right now
     *
     * @return mnet_message the current incoming message (may be null if not in a request right now)
     */
    public static function get_current_request() {
        return self::$request;
    }

    /**
     * Empty private constructor - invoke using static handle_request() instead
     * 
     */
    private function __construct() {
        
    }
    
    /**
     * After instanciating a singleton of this class, this
     * method takes care of actually processing the incoming
     * request
     *
     */
    private function _handle_request() {
        global $MNET, $MNET_APPLICATION;

        $MNET_APPLICATION->log(MNET_LOG_DEBUG, 'Handling incoming request (unknown so far)');

        // Set up output headers
        header('Content-type: text/xml; charset=utf-8', true);

        try {
            // Ensure $_SERVER exists
            if (!isset($_SERVER)) {
                throw new mnet_xmlrpc_fault(712, 'phperror');
            }

            // PHP 5.2.2: $HTTP_RAW_POST_DATA not populated bug:
            // http://bugs.php.net/bug.php?id=41293
            if (empty($HTTP_RAW_POST_DATA)) {
                $HTTP_RAW_POST_DATA = file_get_contents('php://input');
            }

            // Check that something was POSTed
            if (empty($HTTP_RAW_POST_DATA)) {
                throw new mnet_xmlrpc_fault(712, 'norequest');
            }

            // Set the $remote_client to null for now since we don't yet know who we're talking to
            self::$remote_client = null;

            // Construct an mnet_message out of the incoming request
            self::$request = new mnet_message($HTTP_RAW_POST_DATA);

            if (self::$request->get_state() == mnet_message::STATE_INVALID) {
                // the incoming request isn't valid XML
                $MNET_APPLICATION->log(MNET_LOG_ERR, "Incoming XMLRPC request isn't valid XML");
                throw new mnet_xmlrpc_fault(712, 'invalidxml');
            }
            else if (self::$request->get_state() == mnet_message::STATE_CLEARTEXT) {
                // Cleartext XML-RPC is only allowed for system/keyswap
                $method = null; // modifed by reference
                $params = xmlrpc_decode_request(self::$request->get_xml(), $method);

                if ($method != 'system/keyswap') {
                    // Not allowed
                    throw new mnet_xmlrpc_fault(7021, 'forbidden transport');
                }

                $this->dispatch(self::$request->get_xml());
                return;
            }

            // The request is either encrypted or signed, either way it should have a wwwroot
            $wwwroot = self::$request->get_wwwroot();

            if (empty($wwwroot)) {
                throw new mnet_xmlrpc_fault(7020, 'no wwwroot found');
            }

            self::$remote_client = $MNET_APPLICATION->get_peer_by_wwwroot($wwwroot);

            if (!self::$remote_client) {
                throw new mnet_xmlrpc_fault(7020, 'unknown wwwroot');
            }

            // Now continue checking the message
            if (self::$request->get_state() == mnet_message::STATE_SIGNED) {
                // This message is signed but not encrypted. If we trust the host
                // then this is OK
                if (!self::$remote_client->plaintext_is_ok()) {
                    throw new mnet_xmlrpc_fault(7021, 'forbidden transport');
                }
            }
            else if (self::$request->get_state() == mnet_message::STATE_ENCRYPTED) {
                // the message is encrypted. attempt to unlock it with our private key
                try {
                    self::$request->decrypt($MNET->get_keypair_pem());
                }
                catch (mnet_message_encryption_error $e) {
                    // our current key didn't work, hunt through our history for one that might
                    $keypair_history = $MNET_APPLICATION->get_keypair_history();
                    foreach ($keypair_history as $keypair) {
                        try {
                            self::$request->decrypt($keypair['keypair_PEM']);

                            // if we get here -> success
                            // Now we need to inform the client that their public key for us
                            // is out-of-date. We do this by raising a '7025' XMLRPC fault, including
                            // our latest public key in the faultString. This fault will be encrypted
                            // using our older private key that matches the key the client used to
                            // make this request, therefore the client knows they can trust the result
                            // still

                            $MNET_APPLICATION->log(MNET_LOG_NOTICE, 'Replying with 7025 to supply '.self::$remote_client.' with new pubkey');
                            exit($this->fault_xml(7025, $MNET->get_public_key_cert(), $keypair['keypair_PEM']));
                        }
                        catch (mnet_message_encryption_error $e2) {} // ignore
                    }
                    
                    // If we get here, none of our keys worked, error out
                    $MNET_APPLICATION->log(MNET_LOG_ERR, 'Unable to find a working keypair for this request');
                    throw new mnet_xmlrpc_fault(7023, 'encryption invalid (unable to find a working keypair)');
                }
            }

            // We expect the message to be in the signed state
            if (self::$request->get_state() != mnet_message::STATE_SIGNED) {
                // this is rather unexpected... (breaking mnet protocol)
                $MNET_APPLICATION->log(MNET_LOG_ERR, 'Request is not signed when it is expected to be');
                throw new mnet_xmlrpc_fault(711, 'message not signed');
            }

            // Decryption was fine, check the signature is valid for this client
            if (!self::$request->check_signature(self::$remote_client->get_public_key())) {
                $MNET_APPLICATION->log(MNET_LOG_NOTICE, 'Incoming request is not signed with the expected key, checking for an updated pubkey');

                // Call the client back - if their public key has changed, they'll send the 7025 fault (see above)
                self::$remote_client->check_for_key_update(); 

                if (!self::$request->check_signature(self::$remote_client->get_public_key())) {
                    $MNET_APPLICATION->log(MNET_LOG_ERR, 'No new key -> signature is invalid');
                    throw new mnet_xmlrpc_fault(710, 'signature not recognised');
                }
                else {
                  $MNET_APPLICATION->log(MNET_LOG_NOTICE, 'Recovered from wrong incoming key (got updated pubkey)');
                }
            }

            // if we get here, the signature is valid

            // Check and update the protocol and server path in use by this peer
            $peer_protocol = self::$request->get_protocol();
            if ($peer_protocol && $peer_protocol != self::$remote_client->get_protocol()) {
                self::$remote_client->set_protocol($peer_protocol);
                self::$remote_client->commit();
            }
            $server_path = self::$request->get_server_path();
            if ($server_path && $server_path != self::$remote_client->get_server_path()) {
                self::$remote_client->set_server_path($server_path);
                self::$remote_client->commit();
            }

            // unwrap the request
            self::$request->strip_signature();

            // dispatch the XML-RPC request - will output the result directly
            $this->dispatch(self::$request->get_xml());

        }
        catch (Exception $e) {
            if (get_class($e) != 'mnet_xmlrpc_fault') {
                // convert it into one
                $e = new mnet_xmlrpc_fault(712, 'Uncaught exception at server: '.$e);
            }

            // convert the XML-RPC fault exception to a real fault
            exit($this->fault_xml($e->getCode(), $e->getMessage(), $MNET->get_keypair_pem()));
        }
    }

    /**
     * Construct an XML-RPC fault message, encrypting towards the provided
     * private key if supplied
     *
     * @param  int      $code   The ID code of the error message
     * @param  string   $text   The error message
     * @param  resource $privatekey The private key that should be used to sign the response (or null to not sign or encrypt the response)
     * @return string   $text   The XML text of the error message
     */
    private function fault_xml($code, $text, $privatekey) {
        if (!is_numeric($code)) {
            $code = 0;
        }
        $code = intval($code);

        $return = $this->prepare_response('<?xml version="1.0"?>
    <methodResponse>
       <fault>
          <value>
             <struct>
                <member>
                   <name>faultCode</name>
                   <value><int>'.intval($code).'</int></value>
                </member>
                <member>
                   <name>faultString</name>
                   <value><string>'.htmlspecialchars($text).'</string></value>
                </member>
             </struct>
          </value>
       </fault>
    </methodResponse>', $privatekey);

        return $return;
    }

    /**
     * Package a response in any required envelope, and return it to the client
     *
     * @param   string   $response_xml  The XMLRPC response string
     * @param   resource $privatekey    The private key to sign the response with, or null to not sign/encrypt
     * @return  string                  The encoded response string
     */
    private function prepare_response($response_xml, $privatekey) {
        global $MNET, $MNET_APPLICATION;

        $response = new mnet_message($response_xml); 

        if ($privatekey && is_object(self::$remote_client)) {
            $response->sign($privatekey);
            $response->encrypt(self::$remote_client->get_public_key());
        }

        return $response->get_xml();
    }

    /**
     * If security checks are passed, dispatch the request to the extension/method
     *
     * @param  string  $payload    The XML-RPC request
     * @return                     No return val - just echo the response
     */
    private function dispatch($payload) {
        global $MNET, $MNET_APPLICATION;

        // xmlrpc_decode_request returns an array of parameters, and the $method
        // variable (which is passed by reference) is instantiated with the value from
        // the methodName tag in the xml payload
        //            xmlrpc_decode_request($xml,                   &$method)
        $params     = xmlrpc_decode_request($payload, $method);

        $MNET_APPLICATION->log(MNET_LOG_DEBUG, 'Called Method: '.$method.', Params: '.print_r($params, true));

        // $method is in the form extensionname/function or system/function
        // $params is an array of parameters. A parameter might itself be an array.

        // Whitelist characters that are permitted in a method name
        // The method name must not begin with a / - avoid absolute paths
        // A dot character . is only allowed in the filename, i.e. something.php
        if (0 == preg_match("@^[A-Za-z0-9]+/[A-Za-z0-9/_-]+(\.php/)?[A-Za-z0-9_-]+$@", $method)) {
            throw new mnet_xmlrpc_fault(713, 'nosuchfunction');
        }

        if (preg_match("/^system\./", $method)) {
            $callstack  = explode('.', $method);
        } else {
            $callstack  = explode('/', $method);
            // the callstack in this instance might be a legacy call to the "custom" extension,
            // passing array('mod', 'forum', 'lib.php', 'forum_add_instance');
            // Or in modern times, array('mnet', 'extname', 'methodname')
            // If it starts with the first element 'mnet' then we know it's going to name an extension
            // rather than a file path
        }

        // will store the required call params
        $extension = null;
        $method = null;

        if ($callstack[0] == 'system' && count($callstack) == 2) {
            // Special case - map this to ths system extension
            $extension = 'system';
            $method = $callstack[1];
        }
        else if ($callstack[0] == 'mnet' && count($callstack) == 3) {
            // Specifies an MNet-2 extension name directly
            $extension = $callstack[1];
            $method = $callstack[2];
            
        }
        else if (in_array($callstack[0], array('auth', 'enrol', 'portfolio'))) {
            // this is a call to a legacy service from an MNet-1 client. Map it to
            // the appropriate new extension
            switch ($callstack[0]) {
                case 'auth'     : $extension = 'singlesignon'; $method = $callstack[3]; break;
                //case 'enrol'    : $extension = 'enrolment'; break;
                //case 'portfolio': $extension = 'portfolio'; break;
                default         : $extension = null;
            }
        }
        else if ($callstack[0] == 'mod') {
            // This is a legacy MNet-1 client call to the "Custom" extension to execute
            // arbitrary methods. Assume they're targeting a moodle system and use the call_moodle()
            // method to invoke some code within Moodle.
            $extension = 'custom';
            $method = 'call_moodle';
        }

        if (!$extension || !$method) {
            // unknown call
            throw new mnet_xmlrpc_fault(7012, 'nosuchfunction');
        }

        // Get an instance of the extension
        $extension_instance = mnet_extension::get($extension);
        if (!is_object($extension_instance)) {
            throw new mnet_xmlrpc_fault(704, 'nosuchservice');
        }
        
        // Check if they have access to this extension and this method
        if (!$extension_instance->check_call_access(self::$remote_client, $method)) { 
            // access denied
            throw new mnet_xmlrpc_fault(702, 'nosuchservice');
        }

        // Validate the method and params for this extension
        if (!$extension_instance->validate_call($method, $params)) {
            // invalid method or param count
            throw new mnet_xmlrpc_fault(7029, 'invalidcall');
        }

        // Everything checks out, execute this method on this extension via the dummy
        // and get the XML-RPC encoded result
        $xmlrpc_server = xmlrpc_server_create();
        $xmlrpc_method = implode('/', $callstack);
        xmlrpc_server_register_method($xmlrpc_server, $xmlrpc_method, array($this, 'dispatch_dummy'));

        // we pass the actual callback through to the dummy via the $user_data param
        $response = xmlrpc_server_call_method($xmlrpc_server, $payload, array($extension_instance, 'rpc_'.$method), array('encoding' => 'utf-8'));

        $MNET_APPLICATION->log(MNET_LOG_DEBUG, 'Response: '. print_r($response, true));
        
        // Encrypt and sign the XML response
        $response = $this->prepare_response($response, $MNET->get_keypair_pem());

        // output the response to the output stream
        echo $response;

        return;
    }

    /**
	 * Because PHP's XMLRPC calls dfwith the convention of func($methodname, $params_array, $user_data),
	 * we use this dummy adapter to convert it to a normal call of func($param1, $param2, ...) and return
	 * the result. For this, the user data parameter is filled with a callback suitable for call_user_func_array()
	 * 
	 * @param string $method the original xmlrpc method, eg. system/keyswap. Ignored
	 * @param array $params the parameters to pass through
	 * @param callback $callback the callback to actually call, from user data
	 * @return mixed whatever the actual method returns 
     */
    private function dispatch_dummy($method, $params, $callback) {
        return call_user_func_array($callback, $params);
    }
}
